package com.feonix.common.exception;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver;
import org.springframework.web.util.WebUtils;

import com.feonix.common.util.StringUtil;

public class MvcExceptionResolver extends AbstractHandlerExceptionResolver {

	private final Log logger = LogFactory.getLog(getClass());

	public static final String DEFAULT_EXCEPTION_ATTRIBUTE = "exception";

	private Properties exceptionMappings;
	private List<String> excludedExceptions = new ArrayList<String>();
	private String defaultErrorView;
	private Integer defaultStatusCode;
	private Map<String, Integer> statusCodes = new HashMap<String, Integer>();

	private List<String> exceptionMappingKeys = null;

	private String exceptionAttribute = "exception";

	public MvcExceptionResolver() {
		this.excludedExceptions.add("ClientAbortException");
	}

	/**
	 * Set the mappings between exception class names and error view names. The
	 * exception class name can be a substring, with no wildcard support at
	 * present. A value of "ServletException" would match
	 * javax.servlet.ServletException and subclasses, for example. NB: Consider
	 * carefully how specific the pattern is, and whether to include package
	 * information (which isn't mandatory). For example, "Exception" will match
	 * nearly anything, and will probably hide other rules.
	 * "java.lang.Exception" would be correct if "Exception" was meant to define
	 * a rule for all checked exceptions. With more unusual exception names such
	 * as "BaseBusinessException" there's no need to use a FQN.
	 * 
	 * @param mappings
	 */
	public void setExceptionMappings(Properties mappings) {
		this.exceptionMappings = mappings;
		if ((mappings != null) && (mappings.size() > 0)) {
			this.exceptionMappings.put("MultipartException", "support/errorpage/error");
			this.exceptionMappingKeys = new ArrayList<String>();
			String mappingKey = null;
			for (Enumeration<?> enumeration = this.exceptionMappings.propertyNames(); enumeration.hasMoreElements();) {
				String element = (String) enumeration.nextElement();
				if (StringUtil.equals("Exception", element)) {
					mappingKey = "Exception";
				} else {
					this.exceptionMappingKeys.add(element);
				}
			}
			if (StringUtil.isNotEmpty(mappingKey))
				this.exceptionMappingKeys.add(mappingKey);
		}
	}

	/**
	 * Set one or more exceptions to be excluded from the exception mappings.
	 * Excluded exceptions are checked first and if one of them equals the
	 * actual exception, the exception will remain unresolved.
	 * 
	 * @param excludedExceptions
	 */
	public void setExcludedExceptions(List<String> excludedExceptions) {
		if ((excludedExceptions != null) && (excludedExceptions.size() > 0))
			this.excludedExceptions.addAll(excludedExceptions);
	}

	/**
	 * Set the name of the default error view. This view will be returned if no
	 * specific mapping was found.
	 * 
	 * @param defaultErrorView
	 */
	public void setDefaultErrorView(String defaultErrorView) {
		this.defaultErrorView = defaultErrorView;
	}

	/**
	 * Set the HTTP status code that this exception resolver will apply for a
	 * given resolved error view. Keys are view names; values are status codes.
	 * Note that this error code will only get applied in case of a top-level
	 * request. It will not be set for an include request, since the HTTP status
	 * cannot be modified from within an include.
	 * 
	 * If not specified, the default status code will be applied.
	 * 
	 * @param statusCodes
	 */
	public void setStatusCodes(Properties statusCodes) {
		for (Enumeration<?> enumeration = statusCodes.propertyNames(); enumeration.hasMoreElements();) {
			String str = (String) enumeration.nextElement();
			Integer localInteger = new Integer(statusCodes.getProperty(str));
			this.statusCodes.put(str, localInteger);
		}
	}

	/**
	 * An alternative to setStatusCodes(Properties) for use with Java-based
	 * configuration.
	 * 
	 * @param viewName
	 * @param statusCode
	 */
	public void addStatusCode(String viewName, int statusCode) {
		this.statusCodes.put(viewName, Integer.valueOf(statusCode));
	}

	/**
	 * Returns the HTTP status codes provided via setStatusCodes(Properties).
	 * Keys are view names; values are status codes.
	 * 
	 * @return
	 */
	public Map<String, Integer> getStatusCodesAsMap() {
		return Collections.unmodifiableMap(this.statusCodes);
	}

	/**
	 * Set the default HTTP status code that this exception resolver will apply
	 * if it resolves an error view and if there is no status code mapping
	 * defined. Note that this error code will only get applied in case of a
	 * top-level request. It will not be set for an include request, since the
	 * HTTP status cannot be modified from within an include.
	 * 
	 * If not specified, no status code will be applied, either leaving this to
	 * the controller or view, or keeping the servlet engine's default of 200
	 * (OK).
	 * 
	 * @param defaultStatusCode
	 *            HTTP status code value, for example 500
	 *            (HttpServletResponse.SC_INTERNAL_SERVER_ERROR) or 404
	 *            (HttpServletResponse.SC_NOT_FOUND)
	 */
	public void setDefaultStatusCode(int defaultStatusCode) {
		this.defaultStatusCode = Integer.valueOf(defaultStatusCode);
	}

	/**
	 * Set the name of the model attribute as which the exception should be
	 * exposed. Default is "exception". This can be either set to a different
	 * attribute name or to null for not exposing an exception attribute at all.
	 * 
	 * @param exceptionAttribute
	 */
	public void setExceptionAttribute(String exceptionAttribute) {
		this.exceptionAttribute = exceptionAttribute;
	}

	/**
	 * Actually resolve the given exception that got thrown during on handler
	 * execution, returning a ModelAndView that represents a specific error page
	 * if appropriate. May be overridden in subclasses, in order to apply
	 * specific exception checks. Note that this template method will be invoked
	 * after checking whether this resolved applies ("mappedHandlers" etc), so
	 * an implementation may simply proceed with its actual exception handling.
	 */
	protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler,
			Exception ex) {
		this.logger.error("resolve exception : ", ex);
		String str = determineViewName(ex, request);
		if (str != null) {
			Integer statusCode = determineStatusCode(request, str);
			if (statusCode != null) {
				applyStatusCodeIfPossible(request, response, statusCode.intValue());
			}
			return getModelAndView(str, ex, request);
		}

		return null;
	}

	/**
	 * Determine the view name for the given exception, first checking against
	 * the "excludedExecptions", then searching the "exceptionMappings", and
	 * finally using the "defaultErrorView" as a fallback.
	 * 
	 * @param ex
	 * @param request
	 * @return
	 */
	protected String determineViewName(Exception ex, HttpServletRequest request) {
		String viewName = null;
		if (this.excludedExceptions != null) {
			String excetionClassName = ex.getClass().getName();
			for (String excludedException : this.excludedExceptions) {
				if (StringUtil.contains(excetionClassName, excludedException)) {
					return null;
				}
			}
		}

		if (this.exceptionMappingKeys != null) {
			viewName = findMatchingViewName(ex);
		}

		if ((viewName == null) && (this.defaultErrorView != null)) {
			if (this.logger.isDebugEnabled()) {
				this.logger.debug("Resolving to default view '" + this.defaultErrorView + "' for exception of type ["
						+ ex.getClass().getName() + "]");
			}
			viewName = this.defaultErrorView;
		}
		return viewName;
	}

	/**
	 * Find a matching view name in the given exception mappings.
	 * 
	 * @param ex
	 * @return
	 */
	protected String findMatchingViewName(Exception ex) {
		String exceptionMappingValue = null;
		String exMappingKey = null;
		int i = Integer.MAX_VALUE;
		for (String exceptionMappingKey : this.exceptionMappingKeys) {
			int j = getDepth(exceptionMappingKey, ex);
			if ((j >= 0) && (j < i)) {
				i = j;
				exMappingKey = exceptionMappingKey;
				exceptionMappingValue = this.exceptionMappings.getProperty(exceptionMappingKey);
			}

		}

		if ((exceptionMappingValue != null) && (this.logger.isDebugEnabled())) {
			this.logger.debug("Resolving to view '" + exceptionMappingValue + "' for exception of type [" + ex.getClass().getName()
					+ "], based on exception mapping [" + exMappingKey + "]");
		}
		return exceptionMappingValue;
	}

	/**
	 * Return the depth to the superclass matching. 0 means ex matches exactly.
	 * Returns -1 if there's no match. Otherwise, returns depth. Lowest depth
	 * wins.
	 * 
	 * @param exceptionMapping
	 * @param ex
	 * @return
	 */
	protected int getDepth(String exceptionMapping, Exception ex) {
		return getDepth(exceptionMapping, ex.getClass(), 0);
	}

	private int getDepth(String exceptionMapping, Class<?> exceptionClass, int depth) {
		if (exceptionClass.getName().contains(exceptionMapping)) {
			return depth;
		}

		if (exceptionClass.equals(Throwable.class)) {
			return -1;
		}
		return getDepth(exceptionMapping, exceptionClass.getSuperclass(), depth + 1);
	}

	/**
	 * Determine the HTTP status code to apply for the given error view. The
	 * default implementation returns the status code for the given view name
	 * (specified through the statusCodes property), or falls back to the
	 * defaultStatusCode if there is no match.
	 * 
	 * Override this in a custom subclass to customize this behavior.
	 * 
	 * @param request
	 * @param viewName
	 * @return
	 */
	protected Integer determineStatusCode(HttpServletRequest request, String viewName) {
		if (this.statusCodes.containsKey(viewName)) {
			return (this.statusCodes.get(viewName));
		}
		return this.defaultStatusCode;
	}

	/**
	 * Apply the specified HTTP status code to the given response, if possible
	 * (that is, if not executing within an include request).
	 * 
	 * @param request
	 * @param response
	 * @param statusCode
	 */
	protected void applyStatusCodeIfPossible(HttpServletRequest request, HttpServletResponse response, int statusCode) {
		if (!(WebUtils.isIncludeRequest(request))) {
			if (this.logger.isDebugEnabled()) {
				this.logger.debug("Applying HTTP status code " + statusCode);
			}
			response.setStatus(statusCode);
			request.setAttribute("javax.servlet.error.status_code", Integer.valueOf(statusCode));
		}
	}

	/**
	 * Return a ModelAndView for the given request, view name and exception. The
	 * default implementation delegates to getModelAndView(String, Exception).
	 * 
	 * @param viewName
	 * @param ex
	 * @param paramHttpServletRequest
	 * @return
	 */
	protected ModelAndView getModelAndView(String viewName, Exception ex, HttpServletRequest paramHttpServletRequest) {
		return getModelAndView(viewName, ex);
	}

	/**
	 * Return a ModelAndView for the given view name and exception. The default
	 * implementation adds the specified exception attribute. Can be overridden
	 * in subclasses.
	 * 
	 * @param viewName
	 * @param ex
	 * @return
	 */
	protected ModelAndView getModelAndView(String viewName, Exception ex) {
		ModelAndView localModelAndView = new ModelAndView(viewName);
		if (this.exceptionAttribute != null) {
			if (this.logger.isDebugEnabled()) {
				this.logger.debug("Exposing Exception as model attribute '" + this.exceptionAttribute + "'");
			}
			localModelAndView.addObject(this.exceptionAttribute, ex);
		}
		return localModelAndView;
	}

}
